agentmux_srv\backend\blockcontroller/
shell.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! ShellController: manages lifecycle of shell and command blocks.
5//! Port of Go's pkg/blockcontroller/shellcontroller.go.
6//!
7//! State machine:
8//!   INIT ─(start)─> RUNNING ─(exit/stop)─> DONE
9//!   DONE ─(resync+force)─> RUNNING
10//!
11//! I/O model (3 async tasks when running):
12//! 1. PTY read loop: process stdout → FileStore + WPS event
13//! 2. Input loop: input channel → process stdin
14//! 3. Wait loop: monitor process exit, update status
15
16
17use std::io::Read as _;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20use std::sync::Mutex;
21use std::time::{Instant, SystemTime, UNIX_EPOCH};
22
23#[cfg(unix)]
24use libc;
25
26use base64::Engine as _;
27use portable_pty::{native_pty_system, CommandBuilder, PtySize};
28use tokio::sync::mpsc;
29
30use super::{
31    BlockControllerRuntimeStatus, BlockInputUnion, Controller, META_KEY_CMD, META_KEY_CMD_ARGS,
32    META_KEY_CMD_CLEAR_ON_START, META_KEY_CMD_CLOSE_ON_EXIT, META_KEY_CMD_CLOSE_ON_EXIT_DELAY,
33    META_KEY_CMD_CLOSE_ON_EXIT_FORCE, META_KEY_CMD_ENV, META_KEY_CMD_RUN_ONCE,
34    META_KEY_CMD_RUN_ON_START, META_KEY_CONNECTION, STATUS_DONE, STATUS_INIT, STATUS_RUNNING,
35};
36use crate::backend::eventbus::EventBus;
37use crate::backend::shellexec::{ConnInterface, ShellProc};
38use crate::backend::storage::filestore::FileStore;
39use crate::backend::storage::store::Store;
40use crate::backend::obj::{self, MetaMapType, RuntimeOpts};
41use crate::backend::wps;
42
43/// Cap on the out-of-order input reorder buffer (`input_seq_buf`).
44///
45/// The input channel itself is now **unbounded** (`unbounded_channel`): a
46/// bounded `try_send` silently dropped input on burst (large pastes arriving
47/// faster than the PTY write loop drains — the original truncation bug), and
48/// the proper backpressure remedy (`send().await`) can't be applied here
49/// because `send_input` is a synchronous trait method holding a `std::Mutex`.
50/// An unbounded channel guarantees no input is ever dropped; terminal input is
51/// human-paced (and the frontend now chunks large pastes at 4 KB / 5 ms), and
52/// the PTY drain loop keeps up, so the queue does not grow in practice. This
53/// constant only bounds the reorder buffer (pathological out-of-order seqs).
54const SHELL_INPUT_CH_SIZE: usize = 256;
55
56/// Detect the best available interactive shell on Windows.
57///
58/// Mirrors the original Go logic from pkg/util/shellutil/shellutil.go DetectLocalShellPath():
59///   1. Try `pwsh`  (PowerShell 7 — cross-platform)
60///   2. Try `powershell` (Windows PowerShell 5.x)
61///   3. Fall back to `cmd.exe`
62#[cfg(windows)]
63fn detect_local_shell_path_windows() -> String {
64    use std::os::windows::process::CommandExt;
65    use std::process::Command;
66    const CREATE_NO_WINDOW: u32 = 0x08000000;
67    // Try pwsh (PowerShell 7)
68    if Command::new("where")
69        .arg("pwsh")
70        .creation_flags(CREATE_NO_WINDOW)
71        .output()
72        .map(|o| o.status.success())
73        .unwrap_or(false)
74    {
75        return "pwsh".to_string();
76    }
77    // Try powershell (Windows PowerShell 5.x)
78    if Command::new("where")
79        .arg("powershell")
80        .creation_flags(CREATE_NO_WINDOW)
81        .output()
82        .map(|o| o.status.success())
83        .unwrap_or(false)
84    {
85        return "powershell".to_string();
86    }
87    "cmd.exe".to_string()
88}
89
90/// Stub for non-Windows builds (never called due to cfg!(windows) guard).
91#[cfg(not(windows))]
92fn detect_local_shell_path_windows() -> String {
93    "cmd.exe".to_string()
94}
95
96/// PTY read buffer size (matches Go's 4096).
97const PTY_READ_BUF_SIZE: usize = 4096;
98
99/// Inner state protected by mutex.
100/// Grace period (seconds) between SIGTERM and SIGKILL during stop().
101#[allow(dead_code)]
102const KILL_GRACE_SECS: u64 = 5;
103
104struct ShellControllerInner {
105    /// Current process status.
106    proc_status: String,
107    /// Process exit code.
108    proc_exit_code: i32,
109    /// Status version counter (incremented on each change).
110    status_version: i32,
111    /// Connection name for the shell process.
112    conn_name: String,
113    /// Input channel sender (sends to the PTY input loop). Unbounded so input
114    /// is never dropped on burst — see `SHELL_INPUT_CH_SIZE` doc.
115    input_tx: Option<mpsc::UnboundedSender<BlockInputUnion>>,
116    /// Input channel receiver (consumed by the PTY input loop).
117    #[allow(dead_code)]
118    input_rx: Option<mpsc::UnboundedReceiver<BlockInputUnion>>,
119    /// OS PID of the running child process, kept for signal delivery in stop().
120    child_pid: Option<u32>,
121    /// Unix timestamp (ms) when the process was spawned; None until first spawn.
122    spawn_ts_ms: Option<i64>,
123    /// Monotonic instant of the most recent PTY read; None until first output.
124    last_pty_output: Option<Instant>,
125    /// True if this pane is running an agent CLI (e.g. claude).
126    is_agent_pane: bool,
127    /// Next expected input seq number (per-TermViewModel monotonic counter).
128    input_seq_next: u64,
129    /// Out-of-order input packets waiting for their seq slot (capped at SHELL_INPUT_CH_SIZE).
130    input_seq_buf: std::collections::BTreeMap<u64, BlockInputUnion>,
131}
132
133/// Factory function type for creating ConnInterface instances.
134/// This allows dependency injection for testing.
135pub type ConnFactory =
136    Box<dyn Fn(&str, &MetaMapType) -> Result<Box<dyn ConnInterface>, String> + Send + Sync>;
137
138/// ShellController manages one shell or command block.
139pub struct ShellController {
140    /// Controller type: "shell" or "cmd".
141    controller_type: String,
142    tab_id: String,
143    block_id: String,
144    /// Prevents concurrent run() calls.
145    run_lock: Arc<AtomicBool>,
146    /// Protected inner state.
147    inner: Arc<Mutex<ShellControllerInner>>,
148    /// Optional factory for creating ConnInterface (for testing).
149    conn_factory: Mutex<Option<ConnFactory>>,
150    /// WPS broker for publishing events (blockfile, controllerstatus).
151    broker: Option<Arc<wps::Broker>>,
152    /// Event bus (unused for now, reserved for future event routing).
153    #[allow(dead_code)]
154    event_bus: Option<Arc<EventBus>>,
155    /// Wave object store — used to seed cmd:cwd on shell spawn.
156    wstore: Option<Arc<Store>>,
157}
158
159impl ShellController {
160    /// Create a new ShellController.
161    pub fn new(
162        controller_type: String,
163        tab_id: String,
164        block_id: String,
165        broker: Option<Arc<wps::Broker>>,
166        event_bus: Option<Arc<EventBus>>,
167        wstore: Option<Arc<Store>>,
168    ) -> Self {
169        Self {
170            controller_type,
171            tab_id,
172            block_id,
173            run_lock: Arc::new(AtomicBool::new(false)),
174            inner: Arc::new(Mutex::new(ShellControllerInner {
175                proc_status: STATUS_INIT.to_string(),
176                proc_exit_code: 0,
177                status_version: 0,
178                conn_name: String::new(),
179                input_tx: None,
180                input_rx: None,
181                child_pid: None,
182                spawn_ts_ms: None,
183                last_pty_output: None,
184                is_agent_pane: false,
185                input_seq_next: 0,
186                input_seq_buf: std::collections::BTreeMap::new(),
187            })),
188            conn_factory: Mutex::new(None),
189            broker,
190            event_bus,
191            wstore,
192        }
193    }
194
195    /// Set a custom ConnInterface factory (for testing).
196    #[allow(dead_code)]
197    pub fn set_conn_factory(&self, factory: ConnFactory) {
198        *self.conn_factory.lock().unwrap() = Some(factory);
199    }
200
201    /// Try to acquire the run lock. Returns false if already running.
202    fn try_lock_run(&self) -> bool {
203        self.run_lock
204            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
205            .is_ok()
206    }
207
208    /// Release the run lock.
209    fn unlock_run(&self) {
210        self.run_lock.store(false, Ordering::SeqCst);
211    }
212
213    /// Update process status and increment version (must hold inner lock).
214    fn set_status(inner: &mut ShellControllerInner, status: &str) {
215        inner.proc_status = status.to_string();
216        inner.status_version += 1;
217    }
218
219    /// Get the runtime status (snapshot).
220    fn get_status_snapshot(&self) -> BlockControllerRuntimeStatus {
221        let inner = self.inner.lock().unwrap();
222        BlockControllerRuntimeStatus {
223            blockid: self.block_id.clone(),
224            version: inner.status_version,
225            shellprocstatus: inner.proc_status.clone(),
226            shellprocconnname: inner.conn_name.clone(),
227            shellprocexitcode: inner.proc_exit_code,
228            spawn_ts_ms: inner.spawn_ts_ms,
229            is_agent_pane: inner.is_agent_pane,
230        }
231    }
232
233    /// Seconds since last PTY output, or None if no output yet.
234    pub fn last_output_secs_ago(&self) -> Option<u64> {
235        self.inner.lock().unwrap().last_pty_output.map(|t| t.elapsed().as_secs())
236    }
237
238    /// True if this pane is running an agent CLI.
239    #[allow(dead_code)]
240    pub fn is_agent_pane(&self) -> bool {
241        self.inner.lock().unwrap().is_agent_pane
242    }
243
244    /// Check block meta for whether to run on start.
245    fn should_run_on_start(meta: &MetaMapType) -> bool {
246        obj::meta_get_bool(meta, META_KEY_CMD_RUN_ON_START, true)
247    }
248
249    /// Check block meta for run-once mode (used in full lifecycle integration).
250    #[allow(dead_code)]
251    fn should_run_once(meta: &MetaMapType) -> bool {
252        obj::meta_get_bool(meta, META_KEY_CMD_RUN_ONCE, false)
253    }
254
255    /// Check block meta for clear-on-start (used in full lifecycle integration).
256    #[allow(dead_code)]
257    fn should_clear_on_start(meta: &MetaMapType) -> bool {
258        obj::meta_get_bool(meta, META_KEY_CMD_CLEAR_ON_START, false)
259    }
260
261    /// Check block meta for close-on-exit (used in full lifecycle integration).
262    #[allow(dead_code)]
263    fn should_close_on_exit(meta: &MetaMapType) -> bool {
264        obj::meta_get_bool(meta, META_KEY_CMD_CLOSE_ON_EXIT, false)
265    }
266
267    /// Check block meta for force close-on-exit (used in full lifecycle integration).
268    #[allow(dead_code)]
269    fn should_close_on_exit_force(meta: &MetaMapType) -> bool {
270        obj::meta_get_bool(meta, META_KEY_CMD_CLOSE_ON_EXIT_FORCE, false)
271    }
272
273    /// Get the close-on-exit delay in ms (defaults to 2000, used in full lifecycle integration).
274    #[allow(dead_code)]
275    fn close_on_exit_delay_ms(meta: &MetaMapType) -> u64 {
276        match meta.get(META_KEY_CMD_CLOSE_ON_EXIT_DELAY) {
277            Some(serde_json::Value::Number(n)) => n.as_u64().unwrap_or(2000),
278            _ => 2000,
279        }
280    }
281
282    /// Get the connection name from block meta.
283    fn get_conn_name(meta: &MetaMapType) -> String {
284        obj::meta_get_string(meta, META_KEY_CONNECTION, "local")
285    }
286
287    /// Get the command string from block meta.
288    fn get_cmd_str(meta: &MetaMapType) -> String {
289        obj::meta_get_string(meta, META_KEY_CMD, "")
290    }
291
292    /// Get cmd:args array from block meta.
293    fn get_cmd_args(meta: &MetaMapType) -> Vec<String> {
294        match meta.get(META_KEY_CMD_ARGS) {
295            Some(serde_json::Value::Array(arr)) => arr
296                .iter()
297                .filter_map(|v| v.as_str().map(|s| s.to_string()))
298                .collect(),
299            _ => vec![],
300        }
301    }
302
303    /// Check if cmd:interactive is set in block meta.
304    fn is_interactive(meta: &MetaMapType) -> bool {
305        obj::meta_get_bool(meta, "cmd:interactive", false)
306    }
307
308    /// Publish current controller status via the WPS broker.
309    fn publish_status(&self) {
310        if let Some(ref broker) = self.broker {
311            let status = self.get_status_snapshot();
312            super::publish_controller_status(broker, &status);
313        }
314    }
315
316    /// Resolve the initial PTY geometry from the resync `rt_opts` payload.
317    ///
318    /// The agent pane is a custom UI (not xterm.js), so the PTY never receives
319    /// a `fitAddon` resize and must be born at the right width — otherwise the
320    /// first batch of agent/tool output wraps at the fallback width until a
321    /// post-spawn resize RPC lands, and that RPC races controller startup (it
322    /// can fail outright). The frontend computes cols from the pane and passes
323    /// them as `rtopts.termsize` on the `controllerresync` command (see
324    /// `usePtyWidth.ts` / `launch-flow.ts`).
325    ///
326    /// Falls back to the historical 25x200 default when `rt_opts` is absent,
327    /// unparseable, or carries the serde-default termsize (`rows==0 && cols==0`,
328    /// per `obj::is_default_term_size`). Per-field guards let a cols-only
329    /// payload keep the default row count. Each axis is clamped to `[1, 1000]`
330    /// so the `i64 → u16` cast is lossless and a bogus value cannot open a
331    /// zero-size or wrapped-size PTY.
332    /// See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
333    fn pty_size_from_rt_opts(rt_opts: &Option<serde_json::Value>) -> PtySize {
334        // Historical fallback geometry. Cols 200 keeps the agent-pane live-log
335        // from hard-wrapping at ~80 before the dynamic resize lands.
336        const DEFAULT_PTY_ROWS: u16 = 25;
337        const DEFAULT_PTY_COLS: u16 = 200;
338        let (mut rows, mut cols) = (DEFAULT_PTY_ROWS, DEFAULT_PTY_COLS);
339        if let Some(v) = rt_opts {
340            if let Ok(rt) = serde_json::from_value::<RuntimeOpts>(v.clone()) {
341                let ts = &rt.termsize;
342                // rows==0 && cols==0 is the serde default → treat as absent.
343                if !(ts.rows == 0 && ts.cols == 0) {
344                    if ts.cols > 0 {
345                        cols = ts.cols.clamp(1, 1000) as u16;
346                    }
347                    if ts.rows > 0 {
348                        rows = ts.rows.clamp(1, 1000) as u16;
349                    }
350                }
351            }
352        }
353        PtySize {
354            rows,
355            cols,
356            pixel_width: 0,
357            pixel_height: 0,
358        }
359    }
360}
361
362impl Controller for ShellController {
363    fn start(
364        &self,
365        block_meta: MetaMapType,
366        rt_opts: Option<serde_json::Value>,
367        force: bool,
368    ) -> Result<(), String> {
369        let cmd_str_preview = Self::get_cmd_str(&block_meta);
370        let interactive_preview = Self::is_interactive(&block_meta);
371        tracing::info!(
372            block_id = %self.block_id,
373            controller = %self.controller_type,
374            cmd = %cmd_str_preview,
375            interactive = interactive_preview,
376            force = force,
377            "block start requested"
378        );
379
380        // Check if we should run
381        if !force && !Self::should_run_on_start(&block_meta) {
382            tracing::info!(block_id = %self.block_id, "skipping start: run_on_start is false");
383            return Ok(());
384        }
385
386        // Try to acquire run lock
387        if !self.try_lock_run() {
388            return Err("controller is already running".to_string());
389        }
390
391        // Get connection info
392        let conn_name = Self::get_conn_name(&block_meta);
393
394        // Update status to running.
395        {
396            let mut inner = self.inner.lock().unwrap();
397            Self::set_status(&mut inner, STATUS_RUNNING);
398            inner.conn_name = conn_name.clone();
399        }
400
401        // Create input channel. Unbounded: input must never be silently
402        // dropped on burst (the paste-truncation bug). See SHELL_INPUT_CH_SIZE.
403        let (input_tx, input_rx) = mpsc::unbounded_channel();
404        {
405            let mut inner = self.inner.lock().unwrap();
406            inner.input_tx = Some(input_tx);
407            inner.input_seq_next = 0;
408            inner.input_seq_buf.clear();
409        }
410
411        // Publish "running" only AFTER `input_tx` exists, so the event is a
412        // truthful readiness signal: a frontend that resizes the PTY the instant
413        // it sees "running" will not hit `send_input`'s "controller is not
414        // running" guard (which fires while `input_tx` is None). The channel is
415        // unbounded, so a resize enqueued before the input task spawns is
416        // buffered, not lost.
417        // See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
418        self.publish_status();
419
420        // Check if we have a conn_factory (test/mock path)
421        let has_factory = self.conn_factory.lock().unwrap().is_some();
422
423        if has_factory {
424            // Mock path: use ConnInterface factory (synchronous, for tests)
425            let conn_result = {
426                let factory = self.conn_factory.lock().unwrap();
427                factory.as_ref().unwrap()(&conn_name, &block_meta)
428            };
429
430            let mut conn = match conn_result {
431                Ok(c) => c,
432                Err(e) => {
433                    let mut inner = self.inner.lock().unwrap();
434                    Self::set_status(&mut inner, STATUS_DONE);
435                    inner.proc_exit_code = -1;
436                    inner.input_tx = None;
437                    self.unlock_run();
438                    return Err(format!("failed to create connection: {e}"));
439                }
440            };
441
442            if let Err(e) = conn.start() {
443                let mut inner = self.inner.lock().unwrap();
444                Self::set_status(&mut inner, STATUS_DONE);
445                inner.proc_exit_code = -1;
446                inner.input_tx = None;
447                self.unlock_run();
448                return Err(format!("failed to start process: {e}"));
449            }
450
451            let mut shell_proc = ShellProc::new(conn_name, conn);
452            let _done_rx = shell_proc.take_done_rx();
453            let exit_code = shell_proc.wait_and_signal();
454
455            {
456                let mut inner = self.inner.lock().unwrap();
457                inner.proc_exit_code = exit_code;
458                Self::set_status(&mut inner, STATUS_DONE);
459                inner.input_tx = None;
460            }
461            self.publish_status();
462            self.unlock_run();
463            return Ok(());
464        }
465
466        // Real PTY path.
467        //
468        // Open the PTY at the size the frontend computed from the pane (passed
469        // as `rtopts.termsize` on the resync), so the agent CLI and its tools
470        // wrap correctly from the very first byte. The earlier code always
471        // opened at a fixed 200 cols and relied on a post-spawn resize RPC to
472        // correct it — but that RPC races controller startup and could fail
473        // outright, leaving output wrapped at 200 all session. Seeding the size
474        // here removes that race; `pty_size_from_rt_opts` falls back to the
475        // historical 25x200 when no size was supplied (e.g. programmatic
476        // spawns). See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
477        let pty_system = native_pty_system();
478        let pty_size = Self::pty_size_from_rt_opts(&rt_opts);
479
480        let pair = pty_system.openpty(pty_size).map_err(|e| {
481            tracing::error!(block_id = %self.block_id, error = %e, "failed to open PTY");
482            let mut inner = self.inner.lock().unwrap();
483            Self::set_status(&mut inner, STATUS_DONE);
484            inner.proc_exit_code = -1;
485            inner.input_tx = None;
486            self.unlock_run();
487            format!("failed to open PTY: {e}")
488        })?;
489        tracing::info!(block_id = %self.block_id, rows = pty_size.rows, cols = pty_size.cols, "PTY opened");
490
491        // Determine shell command
492        let cmd_str = Self::get_cmd_str(&block_meta);
493        let cmd_args = Self::get_cmd_args(&block_meta);
494        let interactive = Self::is_interactive(&block_meta);
495
496        // Resolve effective AGENTMUX_AGENT_ID for jekt auto-registration.
497        // Priority: block metadata > global settings > WAVEMUX_AGENT_ID env compat.
498        let agent_id_for_jekt: Option<String> = block_meta
499            .get(META_KEY_CMD_ENV)
500            .and_then(|m| m.as_object())
501            .and_then(|obj| obj.get("AGENTMUX_AGENT_ID"))
502            .and_then(|v| v.as_str())
503            .map(|s| s.to_string())
504            .or_else(|| {
505                let cfg = crate::backend::wconfig::ConfigWatcher::with_config(
506                    crate::backend::wconfig::build_default_config(),
507                );
508                cfg.get_settings().cmd_env.get("AGENTMUX_AGENT_ID").cloned()
509            })
510            .or_else(|| std::env::var("WAVEMUX_AGENT_ID").ok());
511
512        let mut cmd = if !cmd_str.is_empty() && (!cmd_args.is_empty() || interactive) {
513            // Direct spawn: cmd:args provided or cmd:interactive set.
514            // Spawn the CLI directly (no sh -c wrapper) so args are passed correctly.
515            tracing::info!(block_id = %self.block_id, cmd = %cmd_str, args = ?cmd_args, "direct spawn path");
516            let mut c = CommandBuilder::new(&cmd_str);
517            if !cmd_args.is_empty() {
518                let arg_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
519                c.args(arg_refs);
520            }
521            c
522        } else if !cmd_str.is_empty() {
523            // "cmd" controller: run a specific command string via shell wrapper
524            tracing::info!(block_id = %self.block_id, cmd = %cmd_str, "shell-wrapped spawn path");
525            if cfg!(windows) {
526                let mut c = CommandBuilder::new("cmd.exe");
527                c.args(["/C", &cmd_str]);
528                c
529            } else {
530                let mut c = CommandBuilder::new("/bin/sh");
531                c.args(["-c", &cmd_str]);
532                c
533            }
534        } else {
535            // "shell" controller: interactive shell with AgentMux integration
536            // On Windows: prefer pwsh (PowerShell 7), fall back to powershell.exe (5.x), then cmd.exe
537            let shell_path = if cfg!(windows) {
538                detect_local_shell_path_windows()
539            } else {
540                std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
541            };
542
543            let shell_type = crate::backend::shellintegration::detect_shell_type(&shell_path);
544
545            // Deploy shell integration scripts to ~/.agentmux/ (the user's home-based
546            // data dir) instead of AGENTMUX_DATA_HOME.  MSIX packages virtualise writes
547            // to %LocalAppData%, so files written by the packaged backend aren't visible
548            // to child processes (pwsh, bash, etc.) spawned via ConPTY.  The home dir is
549            // never virtualised, so the scripts are always reachable at their literal path.
550            let shell_home = crate::backend::base::get_home_dir().join(".agentmux");
551            crate::backend::shellintegration::deploy_scripts(&shell_home);
552
553            tracing::info!(block_id = %self.block_id, shell = %shell_path, shell_type = ?shell_type, "interactive shell path");
554
555            let mut c = CommandBuilder::new(&shell_path);
556
557            // Apply shell-specific startup args (--rcfile, -File, etc.)
558            if let Some(startup) = crate::backend::shellintegration::get_shell_startup(shell_type, &shell_home) {
559                for arg in &startup.extra_args {
560                    c.arg(arg);
561                }
562                for (k, v) in &startup.env_vars {
563                    c.env(k, v);
564                }
565            }
566
567            // Inject terminal capability env vars into the PTY environment.
568            // ConPTY on Windows fully supports VT/ANSI sequences, so set TERM
569            // on all platforms. Without this, CLI tools (e.g. Claude Code) use
570            // different Unicode width tables, causing ANSI color offset on Windows.
571            c.env("TERM", "xterm-256color");
572            c.env("COLORTERM", "truecolor");
573            c.env("TERM_PROGRAM", "agentmux");
574            c.env("AGENTMUX_BLOCKID", &self.block_id);
575            c.env("AGENTMUX_TABID", &self.tab_id);
576            c.env("AGENTMUX_VERSION", env!("CARGO_PKG_VERSION"));
577
578            // Inject log directory so agents can find logs without guessing.
579            // Always ~/.agentmux/logs/ — matches both host and sidecar.
580            let log_dir = dirs::home_dir()
581                .unwrap_or_default()
582                .join(".agentmux")
583                .join("logs");
584            c.env("AGENTMUX_LOG_DIR", log_dir.to_string_lossy().as_ref());
585
586            // Propagate local backend URL so agentbus-client prefers local PTY delivery.
587            // Set by main.rs after binding; absent in test/mock contexts (graceful no-op).
588            if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
589                c.env("AGENTMUX_LOCAL_URL", &local_url);
590            }
591
592            // AGENTMUX is a plain "1" sentinel — wsh has been retired.
593            // Shell integrations check for the presence of AGENTMUX but no
594            // longer prepend a path to $PATH based on its value.
595            // See specs/SPEC_RETIRE_WSH_2026_04_12.md.
596            c.env("AGENTMUX", "1");
597
598            // Wire AgentMux-managed tool dirs into the agent's PATH.
599            //
600            // Two stores, two precedence rules:
601            //
602            //   • Bundled store (`<exe_dir>/tools/bin`) ships the app's OWN
603            //     version-locked binaries — notably `agentmux-bashwrap`, the
604            //     streaming hook that MUST match the running build. It is
605            //     PREPENDED so it wins over any stale copy elsewhere on the
606            //     system PATH. A stale system-PATH `agentmux-bashwrap` (from a
607            //     leftover portable) silently shadowed the fixed one and
608            //     reintroduced the exit-130 bug — see
609            //     docs/retro/RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md. The
610            //     bundled jq/rg winning too is intended: agents get the app's
611            //     curated, deterministic tool versions regardless of host.
612            //
613            //   • User-managed store (`~/.agentmux/tools/bin`) holds tools the
614            //     user installed via /tools. APPENDED so the user's own system
615            //     PATH still wins for those.
616            {
617                let sep = if cfg!(windows) { ";" } else { ":" };
618                let current_path = std::env::var("PATH").unwrap_or_default();
619                let mut prepend: Vec<String> = Vec::new();
620                let mut append: Vec<String> = Vec::new();
621
622                // Bundled store — prepended (app-owned, version-locked).
623                if let Some(bundled_bin) = crate::backend::tool_store::bundled_tools_dir() {
624                    if bundled_bin.exists() {
625                        // Guardrail: log which agentmux-bashwrap the agent will
626                        // actually run. A stale system-PATH copy silently
627                        // shadowing the bundled one is exactly the exit-130
628                        // trap (RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md); this
629                        // one line makes "which binary?" answerable at a glance
630                        // (cross-check the version with `agentmux-bashwrap
631                        // --version`).
632                        let bashwrap_exe = if cfg!(windows) {
633                            "agentmux-bashwrap.exe"
634                        } else {
635                            "agentmux-bashwrap"
636                        };
637                        let bw = bundled_bin.join(bashwrap_exe);
638                        if bw.exists() {
639                            tracing::info!(
640                                target: "agent-tools",
641                                path = %bw.display(),
642                                "agent bashwrap: bundled (version-locked, prepended to PATH)"
643                            );
644                        } else {
645                            tracing::warn!(
646                                target: "agent-tools",
647                                dir = %bundled_bin.display(),
648                                "agent bashwrap: bundled store present but agentmux-bashwrap MISSING — agent will resolve via system PATH (risk of a stale copy; see RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md)"
649                            );
650                        }
651                        prepend.push(bundled_bin.to_string_lossy().into_owned());
652                    } else {
653                        tracing::warn!(
654                            target: "agent-tools",
655                            "agent bashwrap: no bundled tools dir — agent will resolve agentmux-bashwrap via system PATH (risk of a stale copy; see RETRO_BASHWRAP_STALE_BUNDLE_2026_06_13.md)"
656                        );
657                    }
658                }
659
660                // User-managed store — appended (system PATH still wins).
661                if let Some(user_bin) = crate::backend::tool_store::user_tools_dir() {
662                    if user_bin.exists() {
663                        append.push(user_bin.to_string_lossy().into_owned());
664                    }
665                }
666
667                if !prepend.is_empty() || !append.is_empty() {
668                    let mut parts = prepend;
669                    if !current_path.is_empty() {
670                        parts.push(current_path);
671                    }
672                    parts.extend(append);
673                    c.env("PATH", parts.join(sep));
674                }
675            }
676
677            // Inject cmd:env from wconfig settings and block metadata.
678            // Track whether AGENTMUX_AGENT_ID is explicitly set so we know
679            // whether to apply the backward-compat WAVEMUX bridge.
680            let mut has_agent_id = false;
681
682            // Settings (global defaults, lowest priority)
683            let config = crate::backend::wconfig::ConfigWatcher::with_config(
684                crate::backend::wconfig::build_default_config(),
685            );
686            let settings = config.get_settings();
687            for (k, v) in &settings.cmd_env {
688                if k == "AGENTMUX_AGENT_ID" {
689                    has_agent_id = true;
690                }
691                let expanded = crate::backend::base::expand_home_dir_safe(v);
692                c.env(k, expanded.to_string_lossy().as_ref());
693            }
694
695            // Block metadata (per-block overrides, highest priority)
696            if let Some(env_map) = block_meta.get(META_KEY_CMD_ENV) {
697                if let Some(obj) = env_map.as_object() {
698                    for (k, v) in obj {
699                        if let Some(val) = v.as_str() {
700                            if k == "AGENTMUX_AGENT_ID" {
701                                has_agent_id = true;
702                            }
703                            let expanded = crate::backend::base::expand_home_dir_safe(val);
704                            c.env(k, expanded.to_string_lossy().as_ref());
705                        }
706                    }
707                }
708            }
709
710            // Strip host-inherited agent identity unless explicitly configured
711            // in settings.cmd_env or block cmd:env metadata.
712            // This also supersedes the old WAVEMUX backward-compat bridge —
713            // both AGENTMUX_* and WAVEMUX_* vars are removed so new panes
714            // start as plain "Terminal".
715            if !has_agent_id {
716                c.env_remove("AGENTMUX_AGENT_ID");
717                c.env_remove("AGENTMUX_AGENT_COLOR");
718                c.env_remove("AGENTMUX_AGENT_TEXT_COLOR");
719                c.env_remove("WAVEMUX_AGENT_ID");
720                c.env_remove("WAVEMUX_AGENT_COLOR");
721            }
722
723            c
724        };
725
726        // Set working directory if specified
727        let cwd = obj::meta_get_string(&block_meta, super::META_KEY_CMD_CWD, "");
728        if !cwd.is_empty() {
729            cmd.cwd(&cwd);
730        }
731
732        let mut child = pair.slave.spawn_command(cmd).map_err(|e| {
733            tracing::error!(block_id = %self.block_id, error = %e, cmd = %cmd_str, "spawn failed");
734            let mut inner = self.inner.lock().unwrap();
735            Self::set_status(&mut inner, STATUS_DONE);
736            inner.proc_exit_code = -1;
737            inner.input_tx = None;
738            self.unlock_run();
739            format!("failed to spawn command: {e}")
740        })?;
741        tracing::info!(block_id = %self.block_id, "process spawned successfully");
742
743        // Detect agent pane: cmd contains a known agent CLI or has AGENTMUX_AGENT_ID set.
744        let is_agent = agent_id_for_jekt.is_some()
745            || cmd_str.to_lowercase().contains("claude")
746            || cmd_str.to_lowercase().contains("codex")
747            || cmd_str.to_lowercase().contains("gemini")
748            || cmd_str.to_lowercase().contains("qwen");
749
750        // Register PID and record spawn metadata.
751        let spawn_ts_ms = SystemTime::now()
752            .duration_since(UNIX_EPOCH)
753            .map(|d| d.as_millis() as i64)
754            .unwrap_or(0);
755        {
756            let mut inner = self.inner.lock().unwrap();
757            if let Some(pid) = child.process_id() {
758                super::pidregistry::register(&self.block_id, pid);
759                inner.child_pid = Some(pid);
760            }
761            inner.spawn_ts_ms = Some(spawn_ts_ms);
762            inner.is_agent_pane = is_agent;
763        }
764
765        // Auto-register with jekt if AGENTMUX_AGENT_ID was set in the block env.
766        // This maps agent_id → block_id in the ReactiveHandler so jekt can deliver
767        // messages directly to this PTY without a separate /agentmux/reactive/register call.
768        if let Some(ref agent_id) = agent_id_for_jekt {
769            match crate::backend::reactive::get_global_handler()
770                .register_agent(agent_id, &self.block_id, Some(&self.tab_id))
771            {
772                Ok(()) => {
773                    tracing::info!(
774                        block_id = %self.block_id,
775                        agent_id = %agent_id,
776                        "jekt: auto-registered"
777                    );
778                    // Also write to cross-instance file registry.
779                    if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
780                        let data_dir = crate::backend::base::get_wave_data_dir();
781                        crate::backend::reactive::registry::write(
782                            &data_dir,
783                            agent_id,
784                            &local_url,
785                            &self.block_id,
786                        );
787                    }
788                }
789                Err(e) => tracing::warn!(
790                    block_id = %self.block_id,
791                    agent_id = %agent_id,
792                    error = %e,
793                    "jekt: auto-register failed"
794                ),
795            }
796        }
797        tracing::info!(
798            block_id = %self.block_id,
799            wstore_present = self.wstore.is_some(),
800            event_bus_present = self.event_bus.is_some(),
801            "[dnd-debug] pre-seed state after spawn"
802        );
803
804        // Seed cmd:cwd in block meta immediately after spawn so drag-and-drop works
805        // before the shell emits its first OSC 7 (or for shells without integration).
806        if let Some(ref store) = self.wstore {
807            let effective_cwd = if !cwd.is_empty() {
808                cwd.clone()
809            } else {
810                std::env::current_dir()
811                    .map(|p| p.to_string_lossy().to_string())
812                    .unwrap_or_default()
813            };
814            tracing::debug!(block_id = %self.block_id, cwd = %effective_cwd, "seeding cmd:cwd");
815            if !effective_cwd.is_empty() {
816                let oref_str = format!("block:{}", self.block_id);
817                let mut meta_update = MetaMapType::new();
818                meta_update.insert(
819                    super::META_KEY_CMD_CWD.to_string(),
820                    serde_json::Value::String(effective_cwd),
821                );
822                // Only set if not already populated — don't clobber a restored session CWD
823                match store.must_get::<crate::backend::obj::Block>(&self.block_id) {
824                    Ok(block) if obj::meta_get_string(&block.meta, super::META_KEY_CMD_CWD, "").is_empty() => {
825                        match crate::server::service::update_object_meta(store, &oref_str, &meta_update) {
826                            Ok(()) => {
827                                // Re-read updated block and broadcast obj:update so the
828                                // frontend Jotai atom refreshes (update_object_meta only writes
829                                // to SQLite — it does NOT send a WebSocket event on its own).
830                                if let Ok(updated_block) = store.must_get::<crate::backend::obj::Block>(&self.block_id) {
831                                    if let Some(ref event_bus) = self.event_bus {
832                                        let update_data = serde_json::to_value(&obj::WaveObjUpdate {
833                                            updatetype: "update".into(),
834                                            otype: "block".into(),
835                                            oid: self.block_id.clone(),
836                                            obj: Some(obj::wave_obj_to_value(&updated_block)),
837                                        }).ok();
838                                        event_bus.broadcast_event(&crate::backend::eventbus::WSEventType {
839                                            eventtype: "waveobj:update".to_string(),
840                                            oref: oref_str.clone(),
841                                            data: update_data,
842                                        });
843                                        tracing::info!(block_id = %self.block_id, "cmd:cwd seeded and broadcast to frontend");
844                                    } else {
845                                        tracing::warn!(block_id = %self.block_id, "cmd:cwd written to store but no event_bus to broadcast — frontend won't update");
846                                    }
847                                }
848                            }
849                            Err(e) => {
850                                tracing::warn!(block_id = %self.block_id, error = %e, "failed to seed cmd:cwd in store");
851                            }
852                        }
853                    }
854                    Ok(_) => {
855                        tracing::debug!(block_id = %self.block_id, "cmd:cwd already set, skipping seed");
856                    }
857                    Err(e) => {
858                        tracing::warn!(block_id = %self.block_id, error = %e, "failed to read block for cmd:cwd seed");
859                    }
860                }
861            }
862        }
863
864        // Get reader/writer from master
865        let reader = pair.master.try_clone_reader().map_err(|e| {
866            let _ = child.kill();
867            let mut inner = self.inner.lock().unwrap();
868            Self::set_status(&mut inner, STATUS_DONE);
869            inner.proc_exit_code = -1;
870            inner.input_tx = None;
871            self.unlock_run();
872            format!("failed to clone PTY reader: {e}")
873        })?;
874
875        let writer = pair.master.take_writer().map_err(|e| {
876            let _ = child.kill();
877            let mut inner = self.inner.lock().unwrap();
878            Self::set_status(&mut inner, STATUS_DONE);
879            inner.proc_exit_code = -1;
880            inner.input_tx = None;
881            self.unlock_run();
882            format!("failed to take PTY writer: {e}")
883        })?;
884
885        // Spawn PTY read task (blocking I/O → spawn_blocking)
886        let block_id_read = self.block_id.clone();
887        let broker_read = self.broker.clone();
888        let inner_read = self.inner.clone();
889        let is_agent_read = is_agent;
890        tokio::task::spawn_blocking(move || {
891            let mut reader = reader;
892            let mut buf = [0u8; PTY_READ_BUF_SIZE];
893
894            // Phase 1.5 PR 1 (additive): if this is an agent pane,
895            // also try to interpret stdout as Claude Code stream-json
896            // line-by-line, feeding successful parses through
897            // ClaudeTranslator and emitting AgentEvents on a new WPS
898            // scope `agent_event:<block_id>`. The existing raw-chunk
899            // path stays byte-equal — interactive panes (which don't
900            // emit JSON) see no behavior change because every line
901            // fails the JSON parse and is silently dropped. The
902            // future stream-json-mode pane and the drone inspector
903            // (issue #830 / Phase 1.5 PR 3) will be the first real
904            // consumers.
905            let mut translator: Option<crate::agents::translator::claude::ClaudeTranslator> =
906                if is_agent_read {
907                    Some(crate::agents::translator::claude::ClaudeTranslator::new())
908                } else {
909                    None
910                };
911            // Per-block line-buffer (raw bytes — see
912            // `extract_agent_events`). Capped to AGENT_LINE_BUFFER_CAP
913            // so a producer that never emits a newline can't grow
914            // the buffer unboundedly.
915            let mut line_buf: Vec<u8> = Vec::new();
916
917            // OSC extractor: only for agent panes. Terminal panes forward
918            // raw bytes to xterm.js which handles OSC natively — stripping
919            // there would suppress the native window-title update. Agent
920            // panes don't use xterm.js; OSC bytes in FileStore corrupt the
921            // document renderer, so we extract and strip them here.
922            let mut osc_extractor: Option<crate::backend::osc_extractor::OscExtractor> =
923                if is_agent_read {
924                    Some(crate::backend::osc_extractor::OscExtractor::new())
925                } else {
926                    None
927                };
928
929            loop {
930                match reader.read(&mut buf) {
931                    Ok(0) => break, // EOF
932                    Ok(n) => {
933                        inner_read.lock().unwrap().last_pty_output = Some(Instant::now());
934                        if let Some(ref broker) = broker_read {
935                            let raw = &buf[..n];
936
937                            // Extract OSC sequences from agent-pane output.
938                            // `cleaned` has OSC bytes removed; `osc_events` carries
939                            // any normalised Claude Code title strings found in this chunk.
940                            let mut cleaned_storage: Vec<u8> = Vec::new();
941                            let mut osc_events: Vec<crate::backend::osc_extractor::OscEvent> = Vec::new();
942                            if let Some(ref mut ext) = osc_extractor {
943                                let (cleaned, evs) = ext.feed(raw);
944                                cleaned_storage = cleaned;
945                                osc_events = evs;
946                            }
947                            let chunk: &[u8] = if osc_extractor.is_some() {
948                                &cleaned_storage
949                            } else {
950                                raw
951                            };
952
953                            handle_append_block_file(
954                                broker,
955                                &block_id_read,
956                                "term",
957                                chunk,
958                                None, // PTY output is raw terminal data; no FileStore write-through
959                                None, // not an agent output stream; no global mirror
960                            );
961
962                            for ev in &osc_events {
963                                wps::publish_block_activity(broker, &block_id_read, &ev.payload);
964                            }
965
966                            if let Some(ref mut t) = translator {
967                                accumulate_and_translate(
968                                    broker,
969                                    &block_id_read,
970                                    &mut line_buf,
971                                    chunk,
972                                    t,
973                                );
974                            }
975                        }
976                    }
977                    Err(e) => {
978                        tracing::debug!("PTY read error for {}: {}", block_id_read, e);
979                        break;
980                    }
981                }
982            }
983        });
984
985        // Spawn input task (routes input channel → PTY writer + resize + signals)
986        // Owns writer and master — dropping them closes the PTY, causing child to exit.
987        let master = pair.master;
988        tokio::spawn(async move {
989            let mut writer = writer;
990            let mut input_rx = input_rx;
991            while let Some(input) = input_rx.recv().await {
992                if let Some(data) = input.input_data {
993                    use std::io::Write;
994                    if let Err(e) = writer.write_all(&data) {
995                        tracing::debug!("PTY write error: {}", e);
996                        break;
997                    }
998                }
999                if let Some(ref size) = input.term_size {
1000                    let pty_size = PtySize {
1001                        rows: size.rows as u16,
1002                        cols: size.cols as u16,
1003                        pixel_width: 0,
1004                        pixel_height: 0,
1005                    };
1006                    if let Err(e) = master.resize(pty_size) {
1007                        tracing::debug!("PTY resize error: {}", e);
1008                    }
1009                }
1010                if input.sig_name.is_some() {
1011                    // Drop writer + master to close PTY, which terminates the child
1012                    break;
1013                }
1014            }
1015            // writer and master drop here → PTY closes → child gets EOF/terminates
1016        });
1017
1018        // Spawn wait task (monitors process exit)
1019        let inner_wait = Arc::clone(&self.inner);
1020        let block_id_wait = self.block_id.clone();
1021        let agent_id_wait = agent_id_for_jekt.clone();
1022        let broker_wait = self.broker.clone();
1023        let run_lock = Arc::clone(&self.run_lock);
1024        tokio::task::spawn_blocking(move || {
1025            let mut child = child;
1026
1027            // Wait for child to exit (blocking)
1028            let exit_status = child.wait();
1029            let exit_code = match exit_status {
1030                Ok(status) => {
1031                    if status.success() {
1032                        0
1033                    } else {
1034                        // portable-pty ExitStatus doesn't expose raw code on all platforms
1035                        1
1036                    }
1037                }
1038                Err(e) => {
1039                    tracing::warn!("wait error for block {}: {}", block_id_wait, e);
1040                    -1
1041                }
1042            };
1043
1044            tracing::info!(block_id = %block_id_wait, exit_code = exit_code, "process exited");
1045
1046            // Unregister PID from per-pane metrics
1047            super::pidregistry::unregister(&block_id_wait);
1048
1049            // Deregister from jekt — removes the agent_id → block_id mapping so
1050            // subsequent jekt attempts fall back to MessageBus rather than a dead PTY.
1051            crate::backend::reactive::get_global_handler().unregister_block(&block_id_wait);
1052
1053            // Also remove from cross-instance file registry and cloud subscriber.
1054            if let Some(ref agent_id) = agent_id_wait {
1055                let data_dir = crate::backend::base::get_wave_data_dir();
1056                crate::backend::reactive::registry::remove(&data_dir, agent_id);
1057                if let Some(sub) = crate::muxbus::cloud_subscriber::get_global_subscriber() {
1058                    sub.remove_agent(agent_id);
1059                }
1060            }
1061
1062            // Update inner state
1063            {
1064                let mut inner = inner_wait.lock().unwrap();
1065                inner.proc_exit_code = exit_code;
1066                ShellController::set_status(&mut inner, STATUS_DONE);
1067                inner.input_tx = None;
1068            }
1069
1070            // Publish done status
1071            if let Some(ref broker) = broker_wait {
1072                let status = {
1073                    let inner = inner_wait.lock().unwrap();
1074                    BlockControllerRuntimeStatus {
1075                        blockid: block_id_wait.clone(),
1076                        version: inner.status_version,
1077                        shellprocstatus: inner.proc_status.clone(),
1078                        shellprocconnname: inner.conn_name.clone(),
1079                        shellprocexitcode: inner.proc_exit_code,
1080                        spawn_ts_ms: inner.spawn_ts_ms,
1081                        is_agent_pane: inner.is_agent_pane,
1082                    }
1083                };
1084                super::publish_controller_status(broker, &status);
1085            }
1086
1087            // Release run lock
1088            run_lock.store(false, Ordering::SeqCst);
1089        });
1090
1091        // Return immediately — PTY tasks run in background
1092        Ok(())
1093    }
1094
1095    fn stop(&self, _graceful: bool, new_status: &str) -> Result<(), String> {
1096        // Extract what we need from the lock, release it before any async work.
1097        #[allow(unused_variables)] // used under #[cfg(unix)] only
1098        let pid_to_kill = {
1099            let mut inner = self.inner.lock().unwrap();
1100            if inner.proc_status == new_status {
1101                return Ok(());
1102            }
1103            let pid = inner.child_pid;
1104            // Drop the input channel — closes PTY writer → delivers EOF/SIGHUP as
1105            // belt-and-suspenders in case signal delivery fails on the platform.
1106            inner.input_tx = None;
1107            Self::set_status(&mut inner, new_status);
1108            pid
1109        };
1110
1111        // Send SIGTERM to the process group so that child processes spawned by
1112        // the shell (e.g. `claude --dangerously-skip-permissions` and its subtree)
1113        // are also signalled. Negative pid targets the whole process group.
1114        // Schedule SIGKILL after KILL_GRACE_SECS as a backstop for processes
1115        // that ignore or delay on SIGTERM.
1116        #[cfg(unix)]
1117        if let Some(pid) = pid_to_kill {
1118            // SAFETY: kill() is a well-defined POSIX syscall.
1119            unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGTERM) };
1120            tokio::spawn(async move {
1121                tokio::time::sleep(tokio::time::Duration::from_secs(KILL_GRACE_SECS)).await;
1122                unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) };
1123            });
1124        }
1125
1126        Ok(())
1127    }
1128
1129    fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
1130        self.get_status_snapshot()
1131    }
1132
1133    fn send_input(&self, input: BlockInputUnion, seq: Option<u64>) -> Result<(), String> {
1134        let mut inner = self.inner.lock().unwrap();
1135        let tx = match &inner.input_tx {
1136            Some(tx) => tx.clone(),
1137            None => return Err("controller is not running".to_string()),
1138        };
1139        match seq {
1140            None => tx.send(input).map_err(|e| format!("send_input: {e}")),
1141            Some(s) => {
1142                // Detect session reset: seq==0 means the TermViewModel
1143                // restarted and its per-block counter is back at zero.
1144                // (A gap-threshold heuristic was tried and removed — a
1145                // stale/duplicate packet far behind could falsely trigger
1146                // a reset and replay old input.)
1147                if s == 0 && inner.input_seq_next > 0 {
1148                    tracing::info!(
1149                        block_id = %self.block_id,
1150                        prev_next = inner.input_seq_next,
1151                        new_seq = s,
1152                        "input seq reset (session reset detected)"
1153                    );
1154                    inner.input_seq_next = s;
1155                    inner.input_seq_buf.clear();
1156                }
1157
1158                let next = inner.input_seq_next;
1159                if s == next {
1160                    // Advance before sending — a send failure must not leave the
1161                    // backend stuck waiting for a seq the frontend will never resend.
1162                    inner.input_seq_next += 1;
1163                    if let Err(e) = tx.send(input) {
1164                        // Unbounded send only fails if the receiver is gone
1165                        // (controller stopped) — nothing to do but drop quietly.
1166                        tracing::warn!(
1167                            block_id = %self.block_id,
1168                            seq = s,
1169                            "send_input: input channel closed, discarding packet: {e}"
1170                        );
1171                        return Ok(());
1172                    }
1173                    // Drain any buffered out-of-order packets now in order.
1174                    loop {
1175                        let expected = inner.input_seq_next;
1176                        match inner.input_seq_buf.remove(&expected) {
1177                            Some(buffered) => {
1178                                inner.input_seq_next += 1;
1179                                if let Err(e) = tx.send(buffered) {
1180                                    tracing::warn!(
1181                                        block_id = %self.block_id,
1182                                        seq = expected,
1183                                        "send_input drain: input channel closed, discarding buffered packet: {e}"
1184                                    );
1185                                }
1186                            }
1187                            None => break,
1188                        }
1189                    }
1190                    Ok(())
1191                } else if s > next {
1192                    if inner.input_seq_buf.len() < SHELL_INPUT_CH_SIZE {
1193                        inner.input_seq_buf.insert(s, input);
1194                    } else {
1195                        tracing::warn!(block_id = %self.block_id, seq = s, "input reorder buffer full, dropping");
1196                    }
1197                    Ok(())
1198                } else {
1199                    tracing::warn!(block_id = %self.block_id, seq = s, next, "duplicate input seq, discarding");
1200                    Ok(())
1201                }
1202            }
1203        }
1204    }
1205
1206    fn controller_type(&self) -> &str {
1207        &self.controller_type
1208    }
1209
1210    fn block_id(&self) -> &str {
1211        &self.block_id
1212    }
1213
1214    fn as_any(&self) -> &dyn std::any::Any {
1215        self
1216    }
1217}
1218
1219// ---- File operation helpers ----
1220
1221/// Append data to a block's terminal output file, publish a WPS event,
1222/// Maximum size of the per-block line buffer used by the agent-event
1223/// translation path. Past this, the buffer is reset (a producer that
1224/// never emits a newline can't grow it unboundedly). 1 MiB is far
1225/// beyond any plausible stream-json frame size.
1226const AGENT_LINE_BUFFER_CAP: usize = 1024 * 1024;
1227
1228/// Append `chunk` to `line_buf`, drain complete lines, JSON-parse
1229/// each, and run successful parses through `translator`. Returns the
1230/// events the translator emitted. Caller is responsible for
1231/// publishing them.
1232///
1233/// Buffers RAW BYTES (not lossy-decoded strings) so a multi-byte
1234/// UTF-8 character split across two PTY reads decodes cleanly when
1235/// the complete line arrives. Decoding each chunk lossily would
1236/// insert U+FFFD into the middle of words for non-ASCII content
1237/// (CJK, emoji, accented chars), silently corrupting
1238/// `AssistantText`/`Done.response` while the parallel raw-byte WPS
1239/// path stays correct — drone consumers would have no way to
1240/// recover. Reagent P1 + codex P2 on PR #833.
1241///
1242/// Pure function — split out from `accumulate_and_translate` so the
1243/// line-buffering + JSON-fast-reject + translator-call logic is
1244/// unit-testable without spinning up a broker.
1245fn extract_agent_events(
1246    line_buf: &mut Vec<u8>,
1247    chunk: &[u8],
1248    translator: &mut crate::agents::translator::claude::ClaudeTranslator,
1249) -> Vec<crate::agents::types::AgentEvent> {
1250    use crate::agents::translator::Translator as _;
1251    let mut out: Vec<crate::agents::types::AgentEvent> = Vec::new();
1252    line_buf.extend_from_slice(chunk);
1253    if line_buf.len() > AGENT_LINE_BUFFER_CAP {
1254        // No newline in a megabyte — definitely not stream-json.
1255        // Reset to keep memory bounded.
1256        line_buf.clear();
1257        return out;
1258    }
1259    while let Some(nl) = line_buf.iter().position(|&b| b == b'\n') {
1260        let line_bytes: Vec<u8> = line_buf.drain(..=nl).collect();
1261        // Decode the COMPLETE line as lossy UTF-8 — split codepoints
1262        // are now fully present, so lossy-vs-strict only matters for
1263        // genuinely malformed bytes which would also fail strict.
1264        let line = String::from_utf8_lossy(&line_bytes);
1265        let trimmed = line.trim_end_matches(['\n', '\r']);
1266        if !trimmed.starts_with('{') {
1267            // Fast-reject: stream-json frames are JSON objects.
1268            // Skips ANSI escapes, prompts, blank lines, etc.
1269            continue;
1270        }
1271        let Ok(frame) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1272            continue;
1273        };
1274        out.extend(translator.translate(frame));
1275    }
1276    out
1277}
1278
1279/// Publish the events `extract_agent_events` produced on the WPS
1280/// scope `agent_event:<block_id>`. Phase 1.5 PR 1 hook for agent
1281/// panes. Called only when `is_agent` is true at spawn time (see
1282/// read-task closure in `start()`).
1283fn accumulate_and_translate(
1284    broker: &wps::Broker,
1285    block_id: &str,
1286    line_buf: &mut Vec<u8>,
1287    chunk: &[u8],
1288    translator: &mut crate::agents::translator::claude::ClaudeTranslator,
1289) {
1290    for event in extract_agent_events(line_buf, chunk, translator) {
1291        broker.publish(wps::WaveEvent {
1292            event: format!("agent_event:{}", block_id),
1293            scopes: vec![],
1294            sender: String::new(),
1295            persist: 0,
1296            data: Some(serde_json::to_value(&event).unwrap_or_default()),
1297        });
1298    }
1299}
1300
1301/// and write-through to FileStore (if provided).
1302/// Persist `data` to the block's output file and the global transcript zone
1303/// **without** publishing a WPS event. Used by the persistent controller to
1304/// record user-message lines for future history loads (so that
1305/// `parseHistoryLines` can reconstruct `user_message` nodes on reopen) without
1306/// triggering a live-stream append that would produce a duplicate node alongside
1307/// the `agent-message-accepted` UUID node. Same lazy-create semantics as
1308/// `handle_append_block_file`.
1309pub fn persist_to_blockfile_silent(
1310    block_id: &str,
1311    filename: &str,
1312    data: &[u8],
1313    filestore: Option<&Arc<FileStore>>,
1314    global_output_zone: Option<&str>,
1315) {
1316    if let Some(fs) = filestore {
1317        let needs_create = match fs.stat(block_id, filename) {
1318            Ok(None) => true,
1319            Ok(Some(_)) => false,
1320            Err(e) => {
1321                tracing::warn!(
1322                    block_id = %block_id, filename = %filename, error = %e,
1323                    "persist_silent: stat failed; skipping"
1324                );
1325                return;
1326            }
1327        };
1328        if needs_create {
1329            if let Err(e) = fs.make_file(
1330                block_id,
1331                filename,
1332                std::collections::HashMap::new(),
1333                crate::backend::storage::filestore::FileOpts::default(),
1334            ) {
1335                use crate::backend::storage::error::StoreError;
1336                if !matches!(e, StoreError::AlreadyExists) {
1337                    tracing::warn!(
1338                        block_id = %block_id, filename = %filename, error = %e,
1339                        "persist_silent: make_file failed; skipping"
1340                    );
1341                    return;
1342                }
1343            }
1344        }
1345        if let Err(e) = fs.append_data(block_id, filename, data) {
1346            tracing::warn!(
1347                block_id = %block_id, filename = %filename, error = %e,
1348                "persist_silent: append_data failed"
1349            );
1350        }
1351    }
1352    if let Some(zone) = global_output_zone {
1353        if let Some(gfs) = crate::backend::agent_session::global_transcript_store() {
1354            mirror_append_to_global(gfs, zone, data);
1355        }
1356    }
1357}
1358
1359///
1360/// Port of Go's `HandleAppendBlockFile`.
1361///
1362/// The FileStore write is fire-and-forget: if it fails we emit a warning but
1363/// never propagate the error back to the hot stdout-reader path.
1364pub fn handle_append_block_file(
1365    broker: &wps::Broker,
1366    block_id: &str,
1367    filename: &str,
1368    data: &[u8],
1369    filestore: Option<&Arc<FileStore>>,
1370    global_output_zone: Option<&str>,
1371) {
1372    let data64 = base64::engine::general_purpose::STANDARD.encode(data);
1373
1374    let event_data = wps::WSFileEventData {
1375        zoneid: block_id.to_string(),
1376        filename: filename.to_string(),
1377        fileop: wps::FILE_OP_APPEND.to_string(),
1378        data64,
1379    };
1380
1381    let event = wps::WaveEvent {
1382        event: wps::EVENT_BLOCK_FILE.to_string(),
1383        scopes: vec![format!("block:{block_id}")],
1384        sender: String::new(),
1385        persist: 0,
1386        data: serde_json::to_value(&event_data).ok(),
1387    };
1388
1389    broker.publish(event);
1390
1391    // Write-through to FileStore for persistent history (Phase 1.3).
1392    // Create the file lazily on first append; if the file already exists
1393    // we skip make_file and go straight to append_data.
1394    if let Some(fs) = filestore {
1395        let needs_create = match fs.stat(block_id, filename) {
1396            Ok(None) => true,
1397            Ok(Some(_)) => false,
1398            Err(e) => {
1399                tracing::warn!(
1400                    block_id = %block_id,
1401                    filename = %filename,
1402                    error = %e,
1403                    "filestore stat failed; skipping write-through"
1404                );
1405                return;
1406            }
1407        };
1408
1409        if needs_create {
1410            if let Err(e) = fs.make_file(
1411                block_id,
1412                filename,
1413                std::collections::HashMap::new(),
1414                crate::backend::storage::filestore::FileOpts::default(),
1415            ) {
1416                // AlreadyExists is benign (race between two appends); anything
1417                // else is worth warning about.
1418                use crate::backend::storage::error::StoreError;
1419                if !matches!(e, StoreError::AlreadyExists) {
1420                    tracing::warn!(
1421                        block_id = %block_id,
1422                        filename = %filename,
1423                        error = %e,
1424                        "filestore make_file failed; skipping write-through"
1425                    );
1426                    return;
1427                }
1428            }
1429        }
1430
1431        if let Err(e) = fs.append_data(block_id, filename, data) {
1432            tracing::warn!(
1433                block_id = %block_id,
1434                filename = %filename,
1435                error = %e,
1436                "filestore append_data failed"
1437            );
1438        }
1439        // Note: output.idx is NOT updated incrementally here. It is a lazily-built,
1440        // self-validating cache rebuilt by the read path whenever output grows (see
1441        // `rebuild_output_idx` below, invoked from the blockfile:read_range handler
1442        // in app_api.rs). This avoids every incremental-index failure mode (desync on
1443        // write failure, chunk-split lines, blank-line miscounting) at the cost of one
1444        // rescan per output-size change.
1445    }
1446
1447    // Mirror the agent's `output` stream into the GLOBAL transcript zone
1448    // (`agent:<defId>:current`) so the conversation loads when this agent is
1449    // opened from another build/channel. Fire-and-forget, exactly like the
1450    // per-channel write above: a global-store hiccup must never stall the live
1451    // pane. Callers pass `Some(zone)` only for the agent output stream, so we
1452    // never mirror PTY `term` data or non-agent blocks.
1453    if let Some(zone) = global_output_zone {
1454        if let Some(gfs) = crate::backend::agent_session::global_transcript_store() {
1455            mirror_append_to_global(gfs, zone, data);
1456        }
1457    }
1458}
1459
1460/// Append `data` to the global transcript zone's `output` file, creating it
1461/// lazily on first write. Mirrors the per-channel write-through in
1462/// [`handle_append_block_file`]; all errors are logged and swallowed so the
1463/// hot stdout path is never blocked by the global store.
1464fn mirror_append_to_global(gfs: &Arc<FileStore>, zone: &str, data: &[u8]) {
1465    use crate::backend::agent_session::OUTPUT_FILE;
1466    use crate::backend::storage::error::StoreError;
1467
1468    match gfs.stat(zone, OUTPUT_FILE) {
1469        Ok(None) => {
1470            if let Err(e) = gfs.make_file(
1471                zone,
1472                OUTPUT_FILE,
1473                std::collections::HashMap::new(),
1474                crate::backend::storage::filestore::FileOpts::default(),
1475            ) {
1476                if !matches!(e, StoreError::AlreadyExists) {
1477                    tracing::warn!(zone = %zone, error = %e, "global transcripts: make_file failed; skipping mirror");
1478                    return;
1479                }
1480            }
1481        }
1482        Ok(Some(_)) => {}
1483        Err(e) => {
1484            tracing::warn!(zone = %zone, error = %e, "global transcripts: stat failed; skipping mirror");
1485            return;
1486        }
1487    }
1488    if let Err(e) = gfs.append_data(zone, OUTPUT_FILE, data) {
1489        tracing::warn!(zone = %zone, error = %e, "global transcripts: append_data failed");
1490    }
1491}
1492
1493/// Resolve a block's GLOBAL transcript zone (`agent:<defId>:current`) from its
1494/// `agentId` meta, looking the block up in `wstore`. Returns `None` for
1495/// non-agent blocks, when there's no store, or when the block can't be loaded —
1496/// the caller then passes `None` and no global mirror happens. Shared by the
1497/// subprocess / persistent / acp agent controllers.
1498pub(crate) fn resolve_global_output_zone(
1499    wstore: &Option<Arc<crate::backend::storage::store::Store>>,
1500    block_id: &str,
1501) -> Option<String> {
1502    let store = wstore.as_ref()?;
1503    let block = store
1504        .must_get::<crate::backend::obj::Block>(block_id)
1505        .ok()?;
1506    crate::backend::agent_session::agent_zone_for_block_meta(&block.meta)
1507}
1508
1509/// Magic header size for `output.idx`: the first 8 bytes are the `output` byte-size
1510/// the index was built for. The index is valid iff this equals `output`'s current
1511/// size; otherwise it is stale and must be rebuilt.
1512pub(crate) const OUTPUT_IDX_HEADER_LEN: i64 = 8;
1513
1514/// Rebuild `output.idx` from `output` in a single streaming scan and atomically
1515/// replace it. The index is the byte offset of every **non-blank** line, matching
1516/// the reader's line addressing (`String::lines().filter(!trim().is_empty())`).
1517///
1518/// Layout: `[covered_size: u64-LE][offset_0: u64-LE][offset_1]...`. `covered_size`
1519/// records the `output` size this index reflects so the read path can detect
1520/// staleness in O(1) and rebuild only when `output` actually grew.
1521///
1522/// The scan streams `output` in 1 MiB windows so memory stays O(one line + offsets)
1523/// rather than loading the whole (potentially multi-GB) file. Line splitting is
1524/// done on raw bytes; since UTF-8 continuation bytes never collide with `\n`, lines
1525/// are never split mid-codepoint, so per-line `from_utf8_lossy` matches the reader.
1526///
1527/// Returns the number of indexed (non-blank) lines on success, or `None` if
1528/// `output` is unreadable or the index write fails (caller falls back to slow path).
1529pub(crate) fn rebuild_output_idx(
1530    fs: &FileStore,
1531    block_id: &str,
1532    output_size: u64,
1533) -> Option<u64> {
1534    const IDX: &str = "output.idx";
1535    const WIN: i64 = 1 << 20; // 1 MiB read window
1536
1537    // Offsets buffer starts with the covered-size header.
1538    let mut buf: Vec<u8> = Vec::new();
1539    buf.extend_from_slice(&output_size.to_le_bytes());
1540
1541    let mut line_count: u64 = 0;
1542    let mut cursor: u64 = 0; // byte offset where the current line begins
1543    let mut line_buf: Vec<u8> = Vec::new(); // bytes of the current line, excluding '\n'
1544    let mut read_pos: i64 = 0;
1545
1546    let flush_line = |line_buf: &mut Vec<u8>,
1547                      cursor: &mut u64,
1548                      buf: &mut Vec<u8>,
1549                      line_count: &mut u64,
1550                      had_newline: bool| {
1551        // The reader strips a trailing '\r' (CRLF) and treats trim-empty as blank.
1552        let is_blank = String::from_utf8_lossy(line_buf).trim().is_empty();
1553        if !is_blank {
1554            buf.extend_from_slice(&cursor.to_le_bytes());
1555            *line_count += 1;
1556        }
1557        // Advance cursor past this line's bytes (+1 for the consumed '\n').
1558        *cursor += line_buf.len() as u64 + if had_newline { 1 } else { 0 };
1559        line_buf.clear();
1560    };
1561
1562    while read_pos < output_size as i64 {
1563        let (_, chunk) = fs.read_at(block_id, "output", read_pos, WIN).ok()?;
1564        if chunk.is_empty() {
1565            break;
1566        }
1567        for &b in &chunk {
1568            if b == b'\n' {
1569                flush_line(&mut line_buf, &mut cursor, &mut buf, &mut line_count, true);
1570            } else {
1571                line_buf.push(b);
1572            }
1573        }
1574        read_pos += chunk.len() as i64;
1575    }
1576    // Trailing line with no final '\n'.
1577    if !line_buf.is_empty() {
1578        flush_line(&mut line_buf, &mut cursor, &mut buf, &mut line_count, false);
1579    }
1580
1581    if let Ok(None) = fs.stat(block_id, IDX) {
1582        let _ = fs.make_file(
1583            block_id,
1584            IDX,
1585            std::collections::HashMap::new(),
1586            crate::backend::storage::filestore::FileOpts::default(),
1587        );
1588    }
1589    match fs.write_file(block_id, IDX, &buf) {
1590        Ok(()) => {
1591            tracing::info!(block_id = %block_id, lines = line_count, covered = output_size, "output.idx rebuilt");
1592            Some(line_count)
1593        }
1594        Err(e) => {
1595            tracing::warn!(block_id = %block_id, error = %e, "output.idx rebuild write failed");
1596            None
1597        }
1598    }
1599}
1600
1601/// Truncate a block's terminal output file and publish a WPS event.
1602/// Port of Go's `HandleTruncateBlockFile`.
1603#[allow(dead_code)]
1604pub fn handle_truncate_block_file(broker: &wps::Broker, block_id: &str, filename: &str) {
1605    let event_data = wps::WSFileEventData {
1606        zoneid: block_id.to_string(),
1607        filename: filename.to_string(),
1608        fileop: wps::FILE_OP_TRUNCATE.to_string(),
1609        data64: String::new(),
1610    };
1611
1612    let event = wps::WaveEvent {
1613        event: wps::EVENT_BLOCK_FILE.to_string(),
1614        scopes: vec![format!("block:{block_id}")],
1615        sender: String::new(),
1616        persist: 0,
1617        data: serde_json::to_value(&event_data).ok(),
1618    };
1619
1620    broker.publish(event);
1621}
1622
1623#[cfg(test)]
1624mod tests {
1625    use super::*;
1626    use crate::backend::shellexec::MockConn;
1627    use std::sync::Arc;
1628
1629    fn make_shell_meta() -> MetaMapType {
1630        let mut meta = MetaMapType::new();
1631        meta.insert(
1632            "controller".to_string(),
1633            serde_json::Value::String("shell".to_string()),
1634        );
1635        meta
1636    }
1637
1638    fn make_cmd_meta(cmd: &str) -> MetaMapType {
1639        let mut meta = MetaMapType::new();
1640        meta.insert(
1641            "controller".to_string(),
1642            serde_json::Value::String("cmd".to_string()),
1643        );
1644        meta.insert(
1645            "cmd".to_string(),
1646            serde_json::Value::String(cmd.to_string()),
1647        );
1648        meta
1649    }
1650
1651    // ── pty_size_from_rt_opts ────────────────────────────────────────────
1652    // Seeds the initial PTY geometry from the resync `rtopts` payload so the
1653    // agent pane is born at the right width (no post-spawn resize race).
1654    // See docs/analysis/AGENT_PANE_PTY_RESIZE_RACE_2026_06_16.md.
1655
1656    #[test]
1657    fn pty_size_defaults_when_rt_opts_absent() {
1658        let sz = ShellController::pty_size_from_rt_opts(&None);
1659        assert_eq!((sz.rows, sz.cols), (25, 200));
1660    }
1661
1662    #[test]
1663    fn pty_size_defaults_when_termsize_is_serde_default() {
1664        // rows==0 && cols==0 is the serde default → treat as absent.
1665        let v = serde_json::json!({ "termsize": { "rows": 0, "cols": 0 } });
1666        let sz = ShellController::pty_size_from_rt_opts(&Some(v));
1667        assert_eq!((sz.rows, sz.cols), (25, 200));
1668    }
1669
1670    #[test]
1671    fn pty_size_honors_supplied_termsize() {
1672        let v = serde_json::json!({ "termsize": { "rows": 50, "cols": 130 } });
1673        let sz = ShellController::pty_size_from_rt_opts(&Some(v));
1674        assert_eq!((sz.rows, sz.cols), (50, 130));
1675    }
1676
1677    #[test]
1678    fn pty_size_keeps_default_rows_for_cols_only_payload() {
1679        let v = serde_json::json!({ "termsize": { "rows": 0, "cols": 130 } });
1680        let sz = ShellController::pty_size_from_rt_opts(&Some(v));
1681        assert_eq!((sz.rows, sz.cols), (25, 130));
1682    }
1683
1684    #[test]
1685    fn pty_size_clamps_oversized_values() {
1686        let v = serde_json::json!({ "termsize": { "rows": 99999, "cols": 99999 } });
1687        let sz = ShellController::pty_size_from_rt_opts(&Some(v));
1688        assert_eq!((sz.rows, sz.cols), (1000, 1000));
1689    }
1690
1691    #[test]
1692    fn pty_size_defaults_on_unparseable_rt_opts() {
1693        // Unknown keys deserialize to RuntimeOpts default (termsize 0/0) → fallback.
1694        let v = serde_json::json!({ "totally": "unrelated" });
1695        let sz = ShellController::pty_size_from_rt_opts(&Some(v));
1696        assert_eq!((sz.rows, sz.cols), (25, 200));
1697    }
1698
1699    #[test]
1700    fn pty_size_ignores_non_positive_axes() {
1701        // Negative axes fail the `> 0` guard and keep their default.
1702        let v = serde_json::json!({ "termsize": { "rows": -5, "cols": -1 } });
1703        let sz = ShellController::pty_size_from_rt_opts(&Some(v));
1704        assert_eq!((sz.rows, sz.cols), (25, 200));
1705    }
1706
1707    #[test]
1708    fn test_shell_controller_new() {
1709        let ctrl = ShellController::new(
1710            "shell".to_string(),
1711            "tab-1".to_string(),
1712            "block-1".to_string(),
1713            None,
1714            None,
1715            None,
1716        );
1717        assert_eq!(ctrl.controller_type(), "shell");
1718        assert_eq!(ctrl.block_id(), "block-1");
1719
1720        let status = ctrl.get_runtime_status();
1721        assert_eq!(status.shellprocstatus, STATUS_INIT);
1722        assert_eq!(status.blockid, "block-1");
1723        assert_eq!(status.version, 0);
1724    }
1725
1726    #[test]
1727    fn test_shell_controller_start_stop() {
1728        let ctrl = ShellController::new(
1729            "shell".to_string(),
1730            "tab-1".to_string(),
1731            "block-1".to_string(),
1732            None,
1733            None,
1734            None,
1735        );
1736
1737        // Use mock factory so we don't open a real PTY in tests
1738        ctrl.set_conn_factory(Box::new(|_conn_name, _meta| {
1739            Ok(Box::new(MockConn::new(0)) as Box<dyn ConnInterface>)
1740        }));
1741
1742        let meta = make_shell_meta();
1743        let result = ctrl.start(meta, None, false);
1744        assert!(result.is_ok());
1745
1746        // After start with mock, process immediately exits → status is done
1747        let status = ctrl.get_runtime_status();
1748        assert_eq!(status.shellprocstatus, STATUS_DONE);
1749
1750        // Stop should work
1751        let result = ctrl.stop(true, STATUS_DONE);
1752        assert!(result.is_ok());
1753    }
1754
1755    #[test]
1756    fn test_shell_controller_run_on_start_false() {
1757        let ctrl = ShellController::new(
1758            "shell".to_string(),
1759            "tab-1".to_string(),
1760            "block-1".to_string(),
1761            None,
1762            None,
1763            None,
1764        );
1765
1766        let mut meta = make_shell_meta();
1767        meta.insert(
1768            META_KEY_CMD_RUN_ON_START.to_string(),
1769            serde_json::Value::Bool(false),
1770        );
1771
1772        let result = ctrl.start(meta, None, false);
1773        assert!(result.is_ok());
1774
1775        // Should still be in init state (didn't start)
1776        let status = ctrl.get_runtime_status();
1777        assert_eq!(status.shellprocstatus, STATUS_INIT);
1778    }
1779
1780    #[test]
1781    fn test_shell_controller_force_start() {
1782        let ctrl = ShellController::new(
1783            "shell".to_string(),
1784            "tab-1".to_string(),
1785            "block-1".to_string(),
1786            None,
1787            None,
1788            None,
1789        );
1790
1791        ctrl.set_conn_factory(Box::new(|_conn_name, _meta| {
1792            Ok(Box::new(MockConn::new(0)) as Box<dyn ConnInterface>)
1793        }));
1794
1795        let mut meta = make_shell_meta();
1796        meta.insert(
1797            META_KEY_CMD_RUN_ON_START.to_string(),
1798            serde_json::Value::Bool(false),
1799        );
1800
1801        // Force should override run_on_start=false
1802        let result = ctrl.start(meta, None, true);
1803        assert!(result.is_ok());
1804
1805        let status = ctrl.get_runtime_status();
1806        // With mock, immediately exits to done
1807        assert_eq!(status.shellprocstatus, STATUS_DONE);
1808    }
1809
1810    #[test]
1811    fn test_shell_controller_with_conn_factory() {
1812        let ctrl = ShellController::new(
1813            "cmd".to_string(),
1814            "tab-1".to_string(),
1815            "block-1".to_string(),
1816            None,
1817            None,
1818            None,
1819        );
1820
1821        // Set a custom factory that returns a mock with exit code 42
1822        ctrl.set_conn_factory(Box::new(|_conn_name, _meta| {
1823            Ok(Box::new(MockConn::new(42)) as Box<dyn ConnInterface>)
1824        }));
1825
1826        let meta = make_cmd_meta("echo hello");
1827        let result = ctrl.start(meta, None, true);
1828        assert!(result.is_ok());
1829
1830        let status = ctrl.get_runtime_status();
1831        assert_eq!(status.shellprocstatus, STATUS_DONE);
1832        assert_eq!(status.shellprocexitcode, 42);
1833    }
1834
1835    #[test]
1836    fn test_shell_controller_conn_factory_error() {
1837        let ctrl = ShellController::new(
1838            "shell".to_string(),
1839            "tab-1".to_string(),
1840            "block-1".to_string(),
1841            None,
1842            None,
1843            None,
1844        );
1845
1846        ctrl.set_conn_factory(Box::new(|_conn_name, _meta| {
1847            Err("connection refused".to_string())
1848        }));
1849
1850        let meta = make_shell_meta();
1851        let result = ctrl.start(meta, None, true);
1852        assert!(result.is_err());
1853        assert!(result.unwrap_err().contains("connection refused"));
1854
1855        let status = ctrl.get_runtime_status();
1856        assert_eq!(status.shellprocstatus, STATUS_DONE);
1857        assert_eq!(status.shellprocexitcode, -1);
1858    }
1859
1860    #[test]
1861    fn test_shell_controller_send_input_not_running() {
1862        let ctrl = ShellController::new(
1863            "shell".to_string(),
1864            "tab-1".to_string(),
1865            "block-1".to_string(),
1866            None,
1867            None,
1868            None,
1869        );
1870
1871        let result = ctrl.send_input(BlockInputUnion::data(b"hello".to_vec()), None);
1872        assert!(result.is_err());
1873        assert!(result.unwrap_err().contains("not running"));
1874    }
1875
1876    #[test]
1877    fn test_shell_controller_status_version_increments() {
1878        let ctrl = ShellController::new(
1879            "shell".to_string(),
1880            "tab-1".to_string(),
1881            "block-1".to_string(),
1882            None,
1883            None,
1884            None,
1885        );
1886
1887        ctrl.set_conn_factory(Box::new(|_conn_name, _meta| {
1888            Ok(Box::new(MockConn::new(0)) as Box<dyn ConnInterface>)
1889        }));
1890
1891        let v0 = ctrl.get_runtime_status().version;
1892
1893        let meta = make_shell_meta();
1894        ctrl.start(meta, None, true).unwrap();
1895
1896        let v_after = ctrl.get_runtime_status().version;
1897        // Status changed from init → running → done = at least 2 increments
1898        assert!(v_after > v0);
1899    }
1900
1901    #[test]
1902    fn test_controller_trait_as_arc() {
1903        let ctrl: Arc<dyn Controller> = Arc::new(ShellController::new(
1904            "shell".to_string(),
1905            "tab-1".to_string(),
1906            "block-1".to_string(),
1907            None,
1908            None,
1909            None,
1910        ));
1911
1912        assert_eq!(ctrl.controller_type(), "shell");
1913        assert_eq!(ctrl.block_id(), "block-1");
1914        let status = ctrl.get_runtime_status();
1915        assert_eq!(status.shellprocstatus, STATUS_INIT);
1916    }
1917
1918    #[test]
1919    fn test_meta_helpers() {
1920        let mut meta = MetaMapType::new();
1921        assert!(ShellController::should_run_on_start(&meta)); // default true
1922        assert!(!ShellController::should_run_once(&meta)); // default false
1923        assert!(!ShellController::should_clear_on_start(&meta)); // default false
1924        assert!(!ShellController::should_close_on_exit(&meta)); // default false
1925
1926        meta.insert(
1927            META_KEY_CMD_RUN_ON_START.to_string(),
1928            serde_json::Value::Bool(false),
1929        );
1930        assert!(!ShellController::should_run_on_start(&meta));
1931
1932        meta.insert(
1933            META_KEY_CMD_RUN_ONCE.to_string(),
1934            serde_json::Value::Bool(true),
1935        );
1936        assert!(ShellController::should_run_once(&meta));
1937
1938        meta.insert(
1939            META_KEY_CMD_CLEAR_ON_START.to_string(),
1940            serde_json::Value::Bool(true),
1941        );
1942        assert!(ShellController::should_clear_on_start(&meta));
1943    }
1944
1945    #[test]
1946    fn test_close_on_exit_delay() {
1947        let mut meta = MetaMapType::new();
1948        assert_eq!(ShellController::close_on_exit_delay_ms(&meta), 2000); // default
1949
1950        meta.insert(
1951            META_KEY_CMD_CLOSE_ON_EXIT_DELAY.to_string(),
1952            serde_json::json!(5000),
1953        );
1954        assert_eq!(ShellController::close_on_exit_delay_ms(&meta), 5000);
1955    }
1956
1957    #[test]
1958    fn test_conn_name_from_meta() {
1959        let mut meta = MetaMapType::new();
1960        assert_eq!(ShellController::get_conn_name(&meta), "local"); // default
1961
1962        meta.insert(
1963            META_KEY_CONNECTION.to_string(),
1964            serde_json::Value::String("user@host".to_string()),
1965        );
1966        assert_eq!(ShellController::get_conn_name(&meta), "user@host");
1967    }
1968
1969    #[test]
1970    fn test_handle_append_block_file() {
1971        let broker = wps::Broker::new();
1972
1973        // Subscribe to block file events
1974        broker.subscribe(
1975            "test-route",
1976            wps::SubscriptionRequest {
1977                event: wps::EVENT_BLOCK_FILE.to_string(),
1978                scopes: vec!["block:block-1".to_string()],
1979                allscopes: false,
1980            },
1981        );
1982
1983        handle_append_block_file(&broker, "block-1", "term", b"hello world", None, None);
1984
1985        // Check event was published
1986        let _history = broker.read_event_history(wps::EVENT_BLOCK_FILE, "block:block-1", 10);
1987        // Note: events are only persisted if persist > 0, so we verify via the publish mechanism
1988        // The broker successfully processed without panic, which verifies correctness
1989    }
1990
1991    /// Helper: read all non-blank line offsets back out of a rebuilt output.idx,
1992    /// returning (covered_size, offsets).
1993    #[cfg(test)]
1994    fn read_idx(fs: &FileStore, block_id: &str) -> (u64, Vec<u64>) {
1995        let raw = fs.read_file(block_id, "output.idx").unwrap().unwrap();
1996        let covered = u64::from_le_bytes(raw[0..8].try_into().unwrap());
1997        let offsets = raw[8..]
1998            .chunks_exact(8)
1999            .map(|c| u64::from_le_bytes(c.try_into().unwrap()))
2000            .collect();
2001        (covered, offsets)
2002    }
2003
2004    #[test]
2005    fn test_rebuild_output_idx_basic() {
2006        use crate::backend::storage::filestore::FileStore;
2007        let fs = FileStore::open_in_memory().expect("filestore");
2008        let bid = "idx-block";
2009        let data = b"line0\nline1\nline2\n";
2010        fs.make_file(bid, "output", Default::default(), Default::default()).unwrap();
2011        fs.append_data(bid, "output", data).unwrap();
2012
2013        let n = rebuild_output_idx(&fs, bid, data.len() as u64).unwrap();
2014        assert_eq!(n, 3);
2015        let (covered, offsets) = read_idx(&fs, bid);
2016        assert_eq!(covered, data.len() as u64);
2017        // "line0\n"=0, "line1\n"=6, "line2\n"=12
2018        assert_eq!(offsets, vec![0, 6, 12]);
2019    }
2020
2021    #[test]
2022    fn test_rebuild_output_idx_blank_and_crlf_and_no_trailing_nl() {
2023        use crate::backend::storage::filestore::FileStore;
2024        let fs = FileStore::open_in_memory().expect("filestore");
2025        let bid = "idx-block2";
2026        // Blank line (just spaces), a CRLF line, a blank line, and a final line
2027        // with no trailing newline. Non-blank lines start at: 0 ("a\n"),
2028        // 8 ("b\r\n" after "a\n   \n"=6 ... let's compute precisely below).
2029        // bytes: "a\n"   (0..2)
2030        //        "   \n" (2..6)   blank
2031        //        "b\r\n" (6..9)   non-blank -> offset 6
2032        //        "\n"    (9..10)  blank
2033        //        "tail"  (10..14) non-blank, no trailing nl -> offset 10
2034        let data = b"a\n   \nb\r\n\ntail";
2035        fs.make_file(bid, "output", Default::default(), Default::default()).unwrap();
2036        fs.append_data(bid, "output", data).unwrap();
2037
2038        let n = rebuild_output_idx(&fs, bid, data.len() as u64).unwrap();
2039        assert_eq!(n, 3, "a, b(crlf), tail are the 3 non-blank lines");
2040        let (_covered, offsets) = read_idx(&fs, bid);
2041        assert_eq!(offsets, vec![0, 6, 10]);
2042
2043        // Sanity: the recorded offsets really do start the expected non-blank lines.
2044        let full = fs.read_file(bid, "output").unwrap().unwrap();
2045        assert_eq!(&full[0..1], b"a");
2046        assert_eq!(&full[6..7], b"b");
2047        assert_eq!(&full[10..14], b"tail");
2048    }
2049
2050    #[test]
2051    fn test_rebuild_output_idx_empty() {
2052        use crate::backend::storage::filestore::FileStore;
2053        let fs = FileStore::open_in_memory().expect("filestore");
2054        let bid = "idx-empty";
2055        fs.make_file(bid, "output", Default::default(), Default::default()).unwrap();
2056        let n = rebuild_output_idx(&fs, bid, 0).unwrap();
2057        assert_eq!(n, 0);
2058        let (covered, offsets) = read_idx(&fs, bid);
2059        assert_eq!(covered, 0);
2060        assert!(offsets.is_empty());
2061    }
2062
2063    #[test]
2064    fn test_handle_truncate_block_file() {
2065        let broker = wps::Broker::new();
2066        // Should not panic
2067        handle_truncate_block_file(&broker, "block-1", "term");
2068    }
2069
2070    #[test]
2071    fn test_register_and_get_controller() {
2072        let ctrl: Arc<dyn Controller> = Arc::new(ShellController::new(
2073            "shell".to_string(),
2074            "tab-1".to_string(),
2075            "test-register-block".to_string(),
2076            None,
2077            None,
2078            None,
2079        ));
2080
2081        super::super::register_controller("test-register-block", ctrl.clone());
2082
2083        let retrieved = super::super::get_controller("test-register-block");
2084        assert!(retrieved.is_some());
2085        assert_eq!(retrieved.unwrap().block_id(), "test-register-block");
2086
2087        // Cleanup
2088        super::super::delete_controller("test-register-block");
2089        assert!(super::super::get_controller("test-register-block").is_none());
2090    }
2091
2092    #[test]
2093    fn test_resync_creates_shell_controller() {
2094        use crate::backend::obj::Block;
2095
2096        let mut meta = MetaMapType::new();
2097        meta.insert(
2098            "controller".to_string(),
2099            serde_json::Value::String("shell".to_string()),
2100        );
2101        // Disable auto-start so we don't open a real PTY in tests
2102        meta.insert(
2103            META_KEY_CMD_RUN_ON_START.to_string(),
2104            serde_json::Value::Bool(false),
2105        );
2106
2107        let block = Block {
2108            oid: "resync-test-block".to_string(),
2109            version: 1,
2110            meta,
2111            ..Default::default()
2112        };
2113
2114        let result = super::super::resync_controller(&block, "tab-1", None, false, None, None, None, None);
2115        assert!(result.is_ok());
2116
2117        let ctrl = super::super::get_controller("resync-test-block");
2118        assert!(ctrl.is_some());
2119        assert_eq!(ctrl.unwrap().controller_type(), "shell");
2120
2121        // Cleanup
2122        super::super::delete_controller("resync-test-block");
2123    }
2124
2125    /// Phase 1.3 integration test: write output via handle_append_block_file with a
2126    /// FileStore, then verify the data is readable back from the store.
2127    #[test]
2128    fn test_handle_append_block_file_writes_to_filestore() {
2129        use crate::backend::storage::filestore::FileStore;
2130        use std::sync::Arc;
2131
2132        let broker = wps::Broker::new();
2133        let fs = Arc::new(FileStore::open_in_memory().expect("open in-memory filestore"));
2134
2135        let block_id = "test-block-fs";
2136        let filename = "output";
2137
2138        // First append — file does not exist yet; handle_append_block_file must create it lazily.
2139        let line1 = b"line one\n";
2140        handle_append_block_file(&broker, block_id, filename, line1, Some(&fs), None);
2141
2142        // Second append
2143        let line2 = b"line two\n";
2144        handle_append_block_file(&broker, block_id, filename, line2, Some(&fs), None);
2145
2146        // Read back from FileStore
2147        let data = fs.read_file(block_id, filename)
2148            .expect("read_file ok")
2149            .expect("data present");
2150
2151        let text = String::from_utf8(data).expect("valid utf8");
2152        assert!(text.contains("line one"), "expected 'line one' in {:?}", text);
2153        assert!(text.contains("line two"), "expected 'line two' in {:?}", text);
2154
2155        // Also verify total size matches
2156        let stat = fs.stat(block_id, filename).unwrap().unwrap();
2157        assert_eq!(stat.size, (line1.len() + line2.len()) as i64);
2158
2159        // Verify WPS events were also published (broker path still works)
2160        broker.subscribe(
2161            "test-route-fs",
2162            wps::SubscriptionRequest {
2163                event: wps::EVENT_BLOCK_FILE.to_string(),
2164                scopes: vec![format!("block:{}", block_id)],
2165                allscopes: false,
2166            },
2167        );
2168        // Re-publish one more line to confirm broker still receives events alongside filestore
2169        handle_append_block_file(&broker, block_id, filename, b"line three\n", Some(&fs), None);
2170        let stat_after = fs.stat(block_id, filename).unwrap().unwrap();
2171        assert_eq!(stat_after.size, (line1.len() + line2.len() + b"line three\n".len()) as i64);
2172    }
2173
2174    // ────────────────────────────────────────────────────────────────
2175    // Cross-channel global transcript mirror
2176    // ────────────────────────────────────────────────────────────────
2177
2178    #[test]
2179    fn mirror_append_to_global_creates_and_appends() {
2180        use crate::backend::agent_session::OUTPUT_FILE;
2181        let gfs = Arc::new(FileStore::open_in_memory().expect("global filestore"));
2182        let zone = "agent:def-mirror-1:current";
2183
2184        // First append creates the file lazily.
2185        mirror_append_to_global(&gfs, zone, b"{\"type\":\"user\"}\n");
2186        // Second append extends it.
2187        mirror_append_to_global(&gfs, zone, b"{\"type\":\"assistant\"}\n");
2188
2189        let data = gfs
2190            .read_file(zone, OUTPUT_FILE)
2191            .expect("read ok")
2192            .expect("present");
2193        let text = String::from_utf8(data).unwrap();
2194        assert!(text.contains("\"user\""), "got {text:?}");
2195        assert!(text.contains("\"assistant\""), "got {text:?}");
2196        // Two NDJSON lines.
2197        assert_eq!(text.lines().filter(|l| !l.trim().is_empty()).count(), 2);
2198    }
2199
2200    #[test]
2201    fn resolve_global_output_zone_maps_agent_block() {
2202        let wstore = Arc::new(Store::open_in_memory().expect("wstore"));
2203
2204        // Agent-anchored block → zone resolved from agentId meta.
2205        let oid = uuid::Uuid::new_v4().to_string();
2206        let mut meta = MetaMapType::new();
2207        meta.insert("view".to_string(), serde_json::json!("agent"));
2208        meta.insert("agentId".to_string(), serde_json::json!("def-zone-1"));
2209        let mut block = obj::Block {
2210            oid: oid.clone(),
2211            parentoref: String::new(),
2212            version: 1,
2213            runtimeopts: None,
2214            stickers: None,
2215            meta,
2216            subblockids: None,
2217        };
2218        wstore.insert(&mut block).expect("insert block");
2219
2220        let some = Some(wstore.clone());
2221        assert_eq!(
2222            resolve_global_output_zone(&some, &oid).as_deref(),
2223            Some("agent:def-zone-1:current"),
2224        );
2225
2226        // Unknown block id → None (no crash).
2227        assert_eq!(resolve_global_output_zone(&some, "no-such-block"), None);
2228        // No store → None.
2229        assert_eq!(resolve_global_output_zone(&None, &oid), None);
2230    }
2231
2232    // ────────────────────────────────────────────────────────────────
2233    // extract_agent_events — Phase 1.5 PR 1
2234    // ────────────────────────────────────────────────────────────────
2235
2236    use crate::agents::translator::claude::ClaudeTranslator;
2237    use crate::agents::types::AgentEvent;
2238
2239    #[test]
2240    fn extract_agent_events_full_line_translates() {
2241        let mut t = ClaudeTranslator::new();
2242        let mut buf: Vec<u8> = Vec::new();
2243        let line =
2244            br#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}}
2245"#;
2246        let events = extract_agent_events(&mut buf, line, &mut t);
2247        assert_eq!(events.len(), 1);
2248        match &events[0] {
2249            AgentEvent::AssistantText { delta } => assert_eq!(delta, "hello"),
2250            other => panic!("expected AssistantText, got {other:?}"),
2251        }
2252        // Buffer drained.
2253        assert!(buf.is_empty());
2254    }
2255
2256    #[test]
2257    fn extract_agent_events_chunked_line_accumulates() {
2258        // PTY can deliver a single logical line across multiple read()
2259        // calls. The buffer must accumulate across calls and only emit
2260        // once the newline arrives.
2261        let mut t = ClaudeTranslator::new();
2262        let mut buf: Vec<u8> = Vec::new();
2263        // First chunk: prefix of the JSON, no newline.
2264        let events = extract_agent_events(
2265            &mut buf,
2266            br#"{"type":"stream_event","event":{"type":"content_"#,
2267            &mut t,
2268        );
2269        assert!(events.is_empty());
2270        // Second chunk: rest of the JSON + newline.
2271        let events = extract_agent_events(
2272            &mut buf,
2273            br#"block_delta","delta":{"type":"text_delta","text":"hi"}}}
2274"#,
2275            &mut t,
2276        );
2277        assert_eq!(events.len(), 1);
2278        match &events[0] {
2279            AgentEvent::AssistantText { delta } => assert_eq!(delta, "hi"),
2280            other => panic!("expected AssistantText, got {other:?}"),
2281        }
2282    }
2283
2284    #[test]
2285    fn extract_agent_events_drops_non_json_lines() {
2286        // Interactive pane output (ANSI escapes, prompts, blank lines)
2287        // must not produce events.
2288        let mut t = ClaudeTranslator::new();
2289        let mut buf: Vec<u8> = Vec::new();
2290        let pty_text = b"\x1b[2K\x1b[0;0H> some prompt\n[m\nplain text\n";
2291        let events = extract_agent_events(&mut buf, pty_text, &mut t);
2292        assert!(events.is_empty(), "got unexpected events: {events:?}");
2293        // Buffer drained — only the trailing line (if any) is retained.
2294        // Here every chunk had a newline at the end, so buf is empty.
2295        assert!(buf.is_empty());
2296    }
2297
2298    #[test]
2299    fn extract_agent_events_drops_carriage_returns() {
2300        // PTYs in cooked mode often emit \r\n. Trimming should accept
2301        // both.
2302        let mut t = ClaudeTranslator::new();
2303        let mut buf: Vec<u8> = Vec::new();
2304        let line = br#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"crlf"}}}
2305"#;
2306        // Replace the \n with \r\n.
2307        let mut bytes: Vec<u8> = line.to_vec();
2308        let last = bytes.len() - 1;
2309        bytes.insert(last, b'\r');
2310        let events = extract_agent_events(&mut buf, &bytes, &mut t);
2311        assert_eq!(events.len(), 1);
2312    }
2313
2314    #[test]
2315    fn extract_agent_events_drops_malformed_json() {
2316        // A line that starts with `{` but isn't valid JSON should be
2317        // silently dropped.
2318        let mut t = ClaudeTranslator::new();
2319        let mut buf: Vec<u8> = Vec::new();
2320        let events = extract_agent_events(&mut buf, b"{not_valid_json\n", &mut t);
2321        assert!(events.is_empty());
2322    }
2323
2324    #[test]
2325    fn extract_agent_events_resets_oversized_buffer() {
2326        // A producer that never emits newlines for >1 MiB triggers
2327        // the buffer reset so memory stays bounded.
2328        let mut t = ClaudeTranslator::new();
2329        let mut buf: Vec<u8> = Vec::new();
2330        let chunk = vec![b'x'; AGENT_LINE_BUFFER_CAP + 1];
2331        let events = extract_agent_events(&mut buf, &chunk, &mut t);
2332        assert!(events.is_empty());
2333        assert!(
2334            buf.is_empty(),
2335            "buffer should reset past cap, was {} bytes",
2336            buf.len()
2337        );
2338    }
2339
2340    #[test]
2341    fn extract_agent_events_preserves_utf8_across_read_boundary() {
2342        // Reagent P1 / Codex P2 on PR #833: a multi-byte UTF-8
2343        // character split across two PTY reads must decode cleanly
2344        // once the complete line arrives, not lossy-decode each
2345        // half into U+FFFD.
2346        //
2347        // 'こんにちは' = e3 81 93 / e3 82 93 / e3 81 ab / e3 81 a1 / e3 81 af
2348        // Each char is 3 bytes. Split a line mid-character to verify
2349        // the buffered-bytes path preserves the codepoint.
2350        let mut t = ClaudeTranslator::new();
2351        let mut buf: Vec<u8> = Vec::new();
2352        let frame = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"こんにちは"}}}
2353"#;
2354        let frame_bytes = frame.as_bytes();
2355        // Split at byte 60 — somewhere inside one of the multi-byte
2356        // sequences of こんにちは (which starts around byte 75 in the
2357        // JSON). Use a position that's clearly inside the codepoint
2358        // run.
2359        let split = frame_bytes
2360            .iter()
2361            .position(|&b| b == 0xe3)
2362            .expect("expected to find a multi-byte codepoint")
2363            + 1; // split right after the leading byte (mid-codepoint)
2364        let (a, b) = frame_bytes.split_at(split);
2365        let events = extract_agent_events(&mut buf, a, &mut t);
2366        // First chunk had no newline.
2367        assert!(events.is_empty());
2368        let events = extract_agent_events(&mut buf, b, &mut t);
2369        assert_eq!(events.len(), 1);
2370        match &events[0] {
2371            AgentEvent::AssistantText { delta } => {
2372                assert_eq!(
2373                    delta, "こんにちは",
2374                    "UTF-8 must round-trip cleanly across read boundary; got {delta:?}"
2375                );
2376                assert!(!delta.contains('\u{FFFD}'), "no replacement chars");
2377            }
2378            other => panic!("expected AssistantText, got {other:?}"),
2379        }
2380    }
2381
2382    #[test]
2383    fn extract_agent_events_two_lines_one_chunk() {
2384        // PTY can also deliver multiple complete lines in a single
2385        // read(). Verify they're all processed.
2386        let mut t = ClaudeTranslator::new();
2387        let mut buf: Vec<u8> = Vec::new();
2388        let two_lines = br#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"a"}}}
2389{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"b"}}}
2390"#;
2391        let events = extract_agent_events(&mut buf, two_lines, &mut t);
2392        assert_eq!(events.len(), 2);
2393    }
2394}